Stop concurrent withdrawals from grabbing the same UTXO - #569
Open
RodriFS wants to merge 2 commits into
Open
Conversation
RodriFS
marked this pull request as draft
August 17, 2026 15:04
RodriFS
force-pushed
the
fix-concurrent-utxo-selection
branch
from
August 17, 2026 15:50
844527d to
b08467d
Compare
RodriFS
force-pushed
the
fix-concurrent-utxo-selection
branch
from
August 17, 2026 15:51
b08467d to
9837702
Compare
RodriFS
marked this pull request as ready for review
August 17, 2026 15:51
RodriFS
marked this pull request as draft
August 17, 2026 16:00
RodriFS
force-pushed
the
fix-concurrent-utxo-selection
branch
from
August 17, 2026 16:00
9837702 to
8c57b14
Compare
RodriFS
marked this pull request as ready for review
August 17, 2026 16:01
RodriFS
marked this pull request as draft
August 17, 2026 16:34
RodriFS
force-pushed
the
fix-concurrent-utxo-selection
branch
from
August 18, 2026 15:29
14c43c2 to
c603282
Compare
RodriFS
force-pushed
the
fix-concurrent-utxo-selection
branch
from
August 18, 2026 16:10
aed9f50 to
8fdfd1c
Compare
RodriFS
marked this pull request as ready for review
August 18, 2026 16:13
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What was going wrong
Two withdrawals from the same wallet, submitted at nearly the same time, could pick the same UTXO. When that happened one of the two transactions replaced the other on the network (RBF) instead of both going through.
The cause was a gap between two steps: NodeGuard would read which UTXOs were free, choose some, and only afterwards mark them as taken. Two requests that both did the reading before either did the marking would happily choose the same coin. This affected both automatic coin selection and the "use exactly these UTXOs" flow (Changeless withdrawals and channel opens, e.g. from the UI's UTXO selector) — the latter had no protection at all.
What changed
Choosing UTXOs and claiming them is now a single operation that can't be interleaved with another one. There are two entry points, depending on who picks the coins:
LockUTXOs— the caller already knows which UTXOs it wants. Checks nothing else has them, then claims them.SelectAndLockUTXOsAsync— the service reads what's available, picks enough to fund the request, and claims it.Both hold a mutex for their whole duration, so the read/choose/claim sequence is indivisible. The mutex is released before callers go on to build a PSBT, since that's the slow part and doesn't need protecting.
A few things fall out of that:
FailedPrecondition, in the UI as an error toast.Also fixed a pre-existing bug found along the way: the query that ignores one specific request when checking for held UTXOs had a parenthesisation mistake, so the "ignore" only actually worked when that request happened to be in one particular state. Nothing exercised that combination before; the fee-bump case above is the first thing that relies on it.
Notes for review
WithdrawAllFundsstill works out its amount outside the protected step, as it did before this PR. If another request takes some coins in between, the amount goes stale and the selection can no longer cover it — that's detected and fails the request with an error rather than under-funding it, so the exposure is a rare retry, not a wrong transaction.Testing
Unit tests cover the locking and conflict rules, plus the pre-existing bug above (across every state it applies to).
Two end-to-end tests run against a live NodeGuard and bitcoind:
dotnet test test/NodeGuard.Tests --filter "Category!=E2E"for the unit tests;just test-e2efor the rest.